Skip to content

feat(agents): issue-585-feat-incidents-store-and-return-trace-id - #589

Merged
mashkovd merged 5 commits into
mainfrom
feat/agents-issue-585-feat-incidents-store-and-return-trace-id
Oct 12, 2026
Merged

mashkovd merged 5 commits into
mainfrom
feat/agents-issue-585-feat-incidents-store-and-return-trace-id

Conversation

@mctl-agents

@mctl-agents mctl-agents Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Implements accepted proposal mctl-api/issue-585-feat-incidents-store-and-return-trace-id.

Spec: https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/mctl-api/proposals/issue-585-feat-incidents-store-and-return-trace-id/

Generated by mctl-agents Tier 2 implementer (18b0e1d). Hand-added after review:

  • e407e48: test coverage. The generated commit had only the pure derivation and helper tests.
  • 869866c: fixes from the Claude review.
  • b87e160: gate and response-label tests.
  • f2472b6: second review round. A deduped argo incident now follows its newest run: CreateIncident stores the workflow name parsed from the summary, because the producer sends none. Also: a $12::text cast, the MCP reason list, and docs.

What changed

  • Storage. alerts.trace_id and alerts.workflow_name are added as additive ADD COLUMN IF NOT EXISTS columns. Every read path goes through one alertColumns list and one scanAlert function.
  • Dedup. trace_id and workflow_name move together. A new valid trace replaces both, and a hit without a trace keeps both. While no trace is stored, a newer workflow_name replaces the older one. This deviates from the proposal's "workflow_name immutable after create", per review: otherwise one run's name could be paired with another run's trace.
  • Update. Update keeps the stored trace when the caller carries none.
  • Write path. POST and PATCH accept trace_id leniently: an invalid value, including a wrong JSON type, is dropped and never fails the request.
  • Read path. GET /incidents/{id} returns the stored trace as trace_source: "reported". For an argo-workflows incident with no stored trace, it derives the trace at read time from the workflow's audit traceparent and returns it as trace_source: "workflow_audit". The derivation has a 2 s timeout, lets a non-admin read only an audit entry of a team they belong to (canReadAuditEntry; an empty-team platform entry is admin-only, as in GetWorkflowLogs, which is stricter than the proposal's "mirror GetWorkflow"), and never writes back to the row. When no trace can be obtained, the response has exactly one trace_unavailable reason.
  • Labels. GET /incidents does not derive. An argo item without a stored trace says not_derived_on_list, and any other source says not_reported. Write responses (create, patch, ack, resolve) say not_derived / not_reported in the same way, so a missing trace_id always carries a reason. trace_source is set by the handlers, not by the store.
  • Summary URL fallback. It accepts a URL inside parentheses and does not capture trailing sentence punctuation.
  • Audit lookup. audit.Log.LookupByWorkflow returns (nil, nil) when the record does not exist and (nil, err) when the read failed. The old GetByWorkflow callers are untouched; they are tracked in mctl-api#586.
  • Shared validation. telemetry.ValidTraceID and telemetry.TraceIDFromTraceparent are now shared by operations.workflowTraceID and the incident path.
  • MCP. The mctl_get_incident description documents trace_id, trace_source and trace_unavailable.

Tests

Proposal Test
T1, T3 alerts.TestTraceID_RoundTripsThroughEveryReadPath (Create RETURNING, Get, GetByPrefix, List, an ack-style Update round-trip, and a trace-replacing Update)
T2 alerts.TestTraceID_Dedup
T3 (JSON) api.TestAlertJSONTraceKey
T4 api.TestIncidentTrace_InvalidInputIgnoredNotRejected (uppercase, 31 chars, all zeros, a number, an object, a bad workflow_name; PATCH invalid keeps the trace, PATCH valid replaces it)
T5, T6 api.TestDeriveIncidentTrace (all reasons), plus api.TestIncidentTrace_GetDerivesDae24112FromAudit, which runs end to end through GetIncident with the production summary text and checks that nothing is written back
T7 telemetry.TestValidTraceIDAndTraceparent
T8 api.TestIncidentTrace_ListMarksAbsenceWithoutAuditLookup (an argo item gets not_derived_on_list, a non-argo item gets not_reported, and the audit lookup count is 0)
review api.TestCanReadAuditEntry, api.TestIncidentTrace_GetAsNonAdmin (real non-admin users through GetIncident), api.TestIncidentTrace_WriteResponsesAreLabelled, api.TestDeriveIncidentTrace_SummaryURLInProse
review 2 api.TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun (two argo runs under one fingerprint; GET derives run2's trace), alerts.TestTraceID_Dedup case "a trace without a name clears the stale name"
task 2 audit.TestPostgresLogger_LookupByWorkflow (found, (nil, nil) for a missing record, and an error for a cancelled context)

go test ./... passes both without and with TEST_DATABASE_URL, run on a local Postgres with -p 1. Packages share the one database, so a parallel run makes the unrelated internal/surfaceid tests flaky. CI runs them too: validate.yml has a postgres:16 service and uses -p 1. (An earlier version of this body said otherwise; corrected after review.)

T9: mutation proof

Each mutation was applied to the fixed code, the named test was run, and then the code was restored.

# Mutation Failing test and line
M1 (T3) alertColumns: trace_id → ''::text AS trace_id TestTraceID_RoundTripsThroughEveryReadPath: store_trace_test.go:56: Create RETURNING: trace_id="" … want "431486061c652efb376a860d9d656f25"
M2 (T2) dedup trace_id = CASE … → trace_id = EXCLUDED.trace_id TestTraceID_Dedup: store_trace_test.go:154: dedup without trace: trace_id="" … want "008ad8979d34c0509e74d8062b7f92ca"
M3 (T5) deriveIncidentTrace returns audit_unavailable before the lookup TestIncidentTrace_GetDerivesDae24112FromAudit: incident_trace_handlers_test.go:208: got trace_id=<nil> trace_source=<nil> unavailable=audit_unavailable; also fails TestDeriveIncidentTrace/summary_url
M4 (T8) list marking removed TestIncidentTrace_ListMarksAbsenceWithoutAuditLookup: incident_trace_handlers_test.go:178: argo item without a stored trace is unmarked
M5 (T4) ValidTraceID check removed from validIncidentTraceID TestIncidentTrace_InvalidInputIgnoredNotRejected/create_uppercase: incident_trace_handlers_test.go:98: invalid trace_id "431486061C652EFB376A860D9D656F25" was stored
M6 (task 2) LookupByWorkflow returns (nil, nil) on any error TestPostgresLogger_LookupByWorkflow: lookup_postgres_test.go:60: failed read: entry=<nil> err=<nil>, want (nil, error)
M7 canReadAuditEntry: team != "" && → team == "" || TestCanReadAuditEntry: non-admin, platform entry: canReadAuditEntry = true, want false
M8 the implementer's original URL pattern TestDeriveIncidentTrace_SummaryURLInProse: summary "Workflow failed: https://…/mctl-agents-implement-dae24112.": looked up "" … (unavailable="workflow_name_unknown")
M9 the dedup workflow_name = CASE … is removed TestTraceID_Dedup: store_trace_test.go:152: new trace replaces the pair: … workflow_name="wf-first" … want … "wf-second"
M10 Update: trace_id=$12 (unconditional) TestTraceID_RoundTripsThroughEveryReadPath: store_trace_test.go:109: Get after Update without trace: trace_id=""
M11 labelStoredTrace gives non-argo items the underived reason TestIncidentTrace_ListMarksAbsenceWithoutAuditLookup: non-argo item without a trace: … trace_unavailable:not_derived_on_list
M12 the M7 mutation, checked through the handler TestIncidentTrace_GetAsNonAdmin/platform_op: trace_id="431486061c652efb376a860d9d656f25" trace_unavailable="", want "" / "audit_record_forbidden"
M13 create response not labelled TestIncidentTrace_WriteResponsesAreLabelled: create response: trace_id=<nil> trace_unavailable=<nil>
M14 CreateIncident's workflowNameFromSummary fallback removed TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun: incident_trace_handlers_test.go:396: derived trace_id=008ad8979d34c0509e74d8062b7f92ca workflow_name=<nil>, want the newest run (431486061c652efb376a860d9d656f25, run2)
M15 dedup first arm guarded by AND EXCLUDED.workflow_name <> '' TestTraceID_Dedup: store_trace_test.go:155: a trace without a name clears the stale name: … workflow_name="wf-second"

M1, M3 and M4 were re-run after 869866c and still fail. M4 was redone as _ = i so that it fails the test rather than the build.

Rollout check (proposal task 8, after deploy)

mctl_get_incident argo-mctl-agents-implement-dae24112-1791756910 must return trace_id=431486061c652efb376a860d9d656f25 with trace_source=workflow_audit. This check gates closing mctlhq/mctl-agent#97.

Closes #585

🤖 Generated with Claude Code

https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7

Store trace_id and workflow_name on incidents, return them from the
incident endpoints and mctl_get_incident, and derive a trace id at read
time for argo-workflows incidents from the workflow's submit audit record.

Proposal: platform-gitops/agents-state/mctl-api/proposals/issue-585-feat-incidents-store-and-return-trace-id/

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@mctl-agents
mctl-agents Bot requested a review from mashkovd as a code owner October 12, 2026 19:56
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mctl-agents[bot]'s task in 5m 53s —— View job


Review complete — changes requested

  • Read the pinned diff (b5f3eca...18b0e1d)
  • Review correctness, security, concurrency, error handling
  • Check test coverage
  • Build / vet / test — not possible in this sandbox (go build and go test were denied, so compile and test status is unverified; please rely on CI)
  • Post inline findings and submit verdict

0 P1 · 3 P2 · 4 P3 — review, all inline comments bound to 18b0e1d.

P2 — should fix before merge

  1. Trace derivation is an existence oracle for AdminOnly workflows (internal/api/handlers_alerts.go:246). The workflow name fed to the audit lookup is caller-controlled — a tenant developer can POST an incident with source: "argo-workflows" and any workflows.mctl.ai/workflows/<ns>/<name> URL in the summary, then GET it. The team == "" branch grants access, but an empty team means a platform-scoped AdminOnly operation; GetWorkflowLogs (handlers_read.go:520-527) deliberately denies non-admins in exactly that case. The distinct trace_unavailable reasons also leak whether an audit record exists for an arbitrary workflow name.
  2. Dedup updates trace_id but not workflow_name (internal/alerts/store.go:149). With a fingerprint stable across runs, a row can end up holding run A's workflow_name and run B's trace_id. The two fields describe the same run and should move together — or both be first-write-wins. No store test covers the dedup path for the new columns.
  3. Summary URL regex swallows trailing punctuation (internal/api/incident_trace.go:45). ([a-z0-9][a-z0-9.-]*) includes ., so "Workflow failed: https://.../wf-1." captures wf-1., which IsDNS1123Subdomain rejects → workflow_name_unknown for an incident whose workflow name is right there. The one existing test uses an unpunctuated URL. ((?:^|\s) also means a parenthesised URL never matches at all.)

P3 — nice to have

  1. Store.Update now writes trace_id unconditionally (store.go:209). All three current callers load existing first so nothing is lost today, but a future partial update silently blanks a stored trace id.
  2. The "exactly one of trace_id/trace_unavailable" invariant does not hold on the 201 create or 200 patch response — both come back with both empty, the exact misreading the ListIncidents loop was added to prevent. Also: the shadowed-json.RawMessage decode trick has no HTTP-level test.
  3. not_derived_on_list is applied to non-argo incidents too, where not_reported is the terminal answer — it tells MCP clients to spend a GET that cannot change the outcome.
  4. scanAlert sets TraceSource, which types.go documents as response-only; plus the hard-coded workflows.mctl.ai host and the incidentSourceArgo/incidentAuditTimeout constants living under a comment that only describes reasons.

Verified clean

Column and placeholder ordering across the new alertColumns + scanAlert pair is consistent in INSERT (23/$1..$23), Update (12), Get, GetByPrefix and List; pgx.Rows satisfies the pgx.Row parameter; the audit.Log interface addition is implemented by both *Logger and *PostgresLogger, and every in-repo audit.Log consumer passes one of those; the telemetry extraction preserves executor.go's previous traceparent/annotation semantics including the all-zeros rejection; the derivation's 2s timeout derives from the request context and cannot leak a goroutine. No tools were added, so the server_test.go tool count is unaffected.
· branch feat/agents-issue-585-feat-incidents-store-and-return-trace-id

Comment thread internal/api/handlers_alerts.go Outdated
}
deriveIncidentTrace(r.Context(), a, lookup, func(e *audit.Entry) bool {
team := auditEntryTenant(e)
return user.IsAdmin() || team == "" || user.HasTenantAccess(team)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Permissive team == "" turns this into an existence/trace oracle for platform-scoped workflows.

The workflow name fed into the audit lookup is caller-controlled: a tenant developer can POST an incident with source: "argo-workflows" and a summary containing any https://workflows.mctl.ai/workflows/<ns>/<name> URL (or send workflow_name directly), then GET it. The distinct trace_unavailable values (audit_record_not_found vs no_traceparent_in_audit_record) plus a returned trace_id reveal whether an audit record exists for an arbitrary workflow name, and hand over its trace id.

auditEntryTenant(e) == "" means an AdminOnly platform-scoped operation — see the comment at internal/api/handlers_read.go:520-527, where GetWorkflowLogs deliberately denies non-admins in exactly this case. Here the empty-team branch grants access instead. Suggest mirroring the stricter gate:

return user.IsAdmin() || (team != "" && user.HasTenantAccess(team))

Tightening further to require team == a.Tenant for non-admins would also remove the cross-tenant probing surface, since the audit entry's team need not relate to the incident the caller was authorized for.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c. canReadAuditEntry lets a non-admin derive only from an audit entry whose team they belong to, so an empty-team (AdminOnly platform) entry is admin-only. I know you withdrew this P2 in 4252021227, since GetWorkflow already serves the traceparent. I kept the stricter gate anyway: the incident path has no reason to widen access, and argo incidents are posted to admins. The comment on the function now says so. Covered by TestCanReadAuditEntry and, through GetIncident with real non-admin users, by TestIncidentTrace_GetAsNonAdmin (b87e160). Mutation M12 fails that test.

Comment thread internal/alerts/store.go Outdated
created_at, updated_at, resolved_at, acknowledged_by, acknowledged_at,
fingerprint, occurrence_count, last_seen_at`,
updated_at = EXCLUDED.updated_at,
trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Dedup updates trace_id but not workflow_name, so the two can end up describing different runs.

trace_id and workflow_name are two views of the same thing: the run that handled the incident. On the dedup path the new occurrence's trace_id overwrites the stored one, while workflow_name keeps occurrence #1's value (it is absent from the DO UPDATE SET list, and Update at line 209 does not touch it either).

Concretely, with a fingerprint that is stable across runs of the same failing template (the normal case for ArgoWorkflowFailed):

A later consumer reading workflow_name gets wf-a and a trace from wf-b. Symmetrically, if #1 carried a trace and #2 did not, deriveIncidentTrace would never run (reported wins) even though the stored trace is for a stale run.

Either move them together:

trace_id      = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END,
workflow_name = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name ELSE alerts.workflow_name END

or keep first-write-wins for both. Whichever is chosen, a store test asserting the dedup outcome for the pair would pin it down — the dedup path has no test for the new columns at all.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c. The pair now moves together on dedup. A new valid trace replaces both trace_id and workflow_name. A hit without a trace keeps both. While no trace is stored, a newer non-empty workflow_name replaces the older one, so derivation uses the latest run. This deviates from the proposal's "workflow_name immutable after create", which is recorded in the PR body. TestTraceID_Dedup pins all of these cases. Mutation M9 fails it.

Comment thread internal/api/incident_trace.go Outdated
traceSourceAuditDerive = "workflow_audit"
)

var workflowURLRe = regexp.MustCompile(`(?:^|\s)https://workflows\.mctl\.ai/workflows/[a-z0-9-]+/([a-z0-9][a-z0-9.-]*)`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — The capture group swallows trailing punctuation, so a URL ending a sentence silently loses its trace.

([a-z0-9][a-z0-9.-]*) includes . and -, and the match is greedy to end of the run. For the very common summary shape where the URL terminates a sentence:

"Workflow failed: https://workflows.mctl.ai/workflows/argo-workflows/mctl-agents-implement-dae24112."

the capture is mctl-agents-implement-dae24112. — and IsDNS1123Subdomain rejects a trailing dot (the name must end alphanumeric), so line 100's guard fails and deriveIncidentTrace returns workflow_name_unknown for an incident whose workflow name is right there in the summary. A trailing - behaves the same way. The sole existing test ("summary url") uses an unpunctuated URL, so this is not covered.

Easiest fix is to anchor the end of the name instead of relying on the character class:

var workflowURLRe = regexp.MustCompile(`(?:^|\s)https://workflows\.mctl\.ai/workflows/[a-z0-9-]+/([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)`)

Worth adding table cases for a trailing ., a trailing ), and a URL in parentheses — note that (?:^|\s) also means "...failed (https://workflows.mctl.ai/...)" does not match at all, since ( is neither start-of-string nor whitespace.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c. The pattern is now (?:^|[\s(])…/([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?). TestDeriveIncidentTrace_SummaryURLInProse covers a trailing ., a parenthesised URL, ). and a trailing -. Mutation M8, which restores the original pattern, fails it.

Comment thread internal/alerts/store.go Outdated
`UPDATE alerts SET status=$2, analysis=$3, proposed_fix=$4, pr_url=$5, pr_number=$6,
confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11
confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11,
trace_id=$12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Update now clobbers trace_id from the in-memory value, which is a footgun for future callers.

All three current callers (UpdateIncident, AcknowledgeIncident, ResolveIncident) load existing via GetByPrefix first, so a.TraceID is always populated and nothing is lost today. But any future caller that builds a partial *Alert to update a couple of fields will silently blank a stored trace id, and the failure is invisible (no error, the column just becomes the empty string).

Consider making it non-destructive, matching the Create dedup policy — i.e. assign CASE WHEN <param> <> %s THEN <param> ELSE alerts.trace_id END instead of the bare parameter.

If clearing a wrong trace id is a wanted capability it needs an explicit path rather than falling out of a zero value: validIncidentTraceID maps both "absent" and "invalid" to the empty string, so a PATCH can never reset one anyway.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c: trace_id=CASE WHEN $12 <> '' THEN $12 ELSE trace_id END. A partial update that carries no trace now keeps the stored one. Asserted in TestTraceID_RoundTripsThroughEveryReadPath. Mutation M10 fails it.

writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
a := body.Alert

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The "exactly one is set" invariant does not hold on the create or patch response.

incident_trace.go:31 states that exactly one of trace_id / trace_unavailable is set, and the new MCP description promises callers that a missing trace_id always carries a reason. CreateIncident (201, line 97) and UpdateIncident (200, line 323) both return the stored alert without running deriveIncidentTrace and without setting a reason, so an argo-workflows incident created with no trace comes back with both fields empty — exactly the "this incident has no trace" misreading the ListIncidents loop at line 203 was added to prevent.

Setting TraceUnavailable = traceNotDerivedOnList on these two responses as well would make the contract uniform at no cost.

Separately, the shadowed-json.RawMessage decode trick here is subtle and has no HTTP-level test: TestValidIncidentTraceInputs exercises the validators directly, but nothing asserts that POST /api/v1/incidents with "trace_id": 123 still succeeds (rather than 400) and stores no trace, nor that "trace_source" / "trace_unavailable" sent by a client cannot reach the stored row. A small httptest case over the router would lock in the whole point of the shadowing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c. Create, patch, ack and resolve responses go through labelStoredTrace. An argo incident without a trace says not_derived, and any other source says not_reported. TestIncidentTrace_WriteResponsesAreLabelled asserts the 201 and 200 bodies (b87e160). Mutation M13 fails it. The HTTP-level shadowing check, a numeric trace_id returning 201 with nothing stored, is in TestIncidentTrace_InvalidInputIgnoredNotRejected.

Comment thread internal/api/handlers_alerts.go Outdated
// never read as "this incident has no trace".
for i := range items {
if items[i].TraceID == "" {
items[i].TraceUnavailable = traceNotDerivedOnList

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — not_derived_on_list is also applied to incidents where derivation could never produce a trace.

For a non-argo-workflows incident deriveIncidentTrace returns not_reported — a terminal answer. Labelling those not_derived_on_list tells the caller (and the MCP client, per the new tool description: "call this tool to resolve it") to spend a GET /incidents/{id} that cannot possibly change the outcome. Gating on the source keeps the two reasons honest:

for i := range items {
    if items[i].TraceID != "" {
        continue
    }
    if items[i].Source == incidentSourceArgo {
        items[i].TraceUnavailable = traceNotDerivedOnList
    } else {
        items[i].TraceUnavailable = traceNotReported
    }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c. On the list, not_derived_on_list is only applied to argo incidents, and non-argo incidents without a trace get not_reported. The list test now seeds a non-argo untraced item. Mutation M11 fails it.

Comment thread internal/alerts/store.go Outdated
}
a.TraceSource = ""
if a.TraceID != "" {
a.TraceSource = "reported"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The store sets a field its own doc comment calls response-only.

types.go:82-84 says TraceSource is "response-only and never persisted", yet scanAlert derives it on every read path, including List and Summary. It is harmless (nothing writes it back, and deriveIncidentTrace resets it on the one path that matters) but it puts presentation logic in the persistence layer and gives List results a trace_source that no derivation produced.

Dropping the two lines and letting the handlers own both response-only fields would keep the layering consistent with the comment.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c. scanAlert no longer sets TraceSource; the handlers set both response-only fields.

)

// trace_unavailable reasons. Exactly one is set when no trace id is returned.
const (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Two nits on this const block.

  1. The Argo UI hostname is hard-coded in workflowURLRe (line 45) and appears nowhere else in non-test code, so a different or additional Argo host (staging, a self-hosted install) silently breaks summary-based derivation with no way to configure it. Since producers are expected to send workflow_name directly, the regex is a legacy-row fallback — worth saying so in the comment, or sourcing the host from the same config the notifier uses.
  2. incidentSourceArgo and incidentAuditTimeout sit in a block whose comment reads "trace_unavailable reasons. Exactly one is set when no trace id is returned." Splitting the timeout and the source constant into their own block would stop the comment from being wrong about three of its members.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c. The constants are split into reasons, sources and the source/timeout pair. The workflowURLRe comment says it is the fallback for rows posted without workflow_name, which is every argo incident today.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 0 P1, 3 P2, 4 P3.

P2: (1) GetIncident grants trace derivation when the audit entry has no owning team, and the looked-up workflow name is caller-controlled via summary/workflow_name — an existence-and-trace oracle for AdminOnly platform workflows, contrary to the stricter gate GetWorkflowLogs uses for the same empty-team case. (2) The Create dedup path updates trace_id but not workflow_name, so a row can end up pairing one run name with another run trace. (3) The summary URL regex captures a trailing ./-, so a URL ending a sentence fails DNS1123 validation and yields workflow_name_unknown.

Column/placeholder ordering in the new alertColumns + scanAlert pair was checked and is consistent across INSERT, Update, Get, GetByPrefix and List; the audit.Log interface addition is satisfied by both implementations. go build and go test were not available in this review sandbox, so compile and test status is unverified.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 18b0e1d.

Findings

1. Inconsistent conflict resolution drops or desynchronizes workflow_name on alert deduplication

  • Severity: P2
  • File: internal/alerts/store.go (approx. line 149)
  • Anchor: + trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END
  • Failure Scenario:
    When Store.Create encounters an existing open alert matching (tenant, fingerprint), the ON CONFLICT clause executes DO UPDATE SET. While trace_id is updated when the incoming alert provides one, workflow_name is omitted from DO UPDATE SET.
    This causes several failure modes:
    1. If an alert was initially created without a workflow name, subsequent occurrences that supply a workflow_name will have it silently dropped.
    2. If a subsequent occurrence supplies both a new trace_id and a new workflow_name, trace_id is updated in the database while workflow_name remains set to the initial run's workflow name, resulting in desynchronized metadata.
    3. If a subsequent occurrence omits trace_id but supplies workflow_name, the alert will retain an empty trace_id and the new workflow name will be discarded, causing subsequent calls to GetIncident to derive traces using the stale (or empty) workflow name instead of the latest occurrence.
  • Proposed Fix:
    Add workflow_name update logic to the DO UPDATE SET clause:
    trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END,
    workflow_name = CASE WHEN EXCLUDED.workflow_name <> '' THEN EXCLUDED.workflow_name ELSE alerts.workflow_name END

2. No test visible in this diff for alert store persistence and conflict handling of trace metadata

  • Severity: P3
  • File: internal/alerts/store.go (approx. line 87)
  • Anchor: +func scanAlert(row pgx.Row, a *Alert) error {
  • Failure Scenario:
    No test visible in this diff verifies the PostgreSQL persistence layer changes for trace_id and workflow_name, including scanAlert, Store.Create with conflict updates, Store.Update, and query retrieval via Get, GetByPrefix, and List.
  • Proposed Fix:
    Add unit or integration tests for Store covering alert creation, deduplication conflict updates on (tenant, fingerprint) with trace_id and workflow_name, and alert retrieval with scanAlert.

3. No test visible in this diff for PostgresLogger.LookupByWorkflow

  • Severity: P3
  • File: internal/audit/postgres.go (approx. line 188)
  • Anchor: +func (p *PostgresLogger) LookupByWorkflow(ctx context.Context, workflowName string) (*Entry, error) {
  • Failure Scenario:
    No test visible in this diff verifies PostgresLogger.LookupByWorkflow, specifically testing that pgx.ErrNoRows is translated to (nil, nil), database scan errors are wrapped with fmt.Errorf, and caller contexts/cancellations are respected.
  • Proposed Fix:
    Add a test verifying LookupByWorkflow returns (nil, nil) when no record matches, returns an error on query failure, and properly populates *audit.Entry when a record exists.

4. No test visible in this diff for incident HTTP handlers' trace derivation and serialization

  • Severity: P3
  • File: internal/api/handlers_alerts.go (approx. line 244)
  • Anchor: + deriveIncidentTrace(r.Context(), a, lookup, func(e *audit.Entry) bool {
  • Failure Scenario:
    While internal/api/incident_trace_test.go tests the core derivation logic in isolation, no test visible in this diff exercises the HTTP handlers in handlers_alerts.go to ensure GetIncident applies trace derivation and permission filtering, ListIncidents sets trace_unavailable = "not_derived_on_list" for missing trace IDs, and CreateIncident/UpdateIncident correctly accept valid trace payloads.
  • Proposed Fix:
    Add HTTP handler tests for GetIncident, ListIncidents, CreateIncident, and UpdateIncident validating trace ID handling and JSON responses.

The implementer PR shipped only the pure derivation and helper tests;
the proposal's store round-trip (T1), dedup (T2), every-read-path
regression (T3), lenient-input (T4) and list-marking (T8) tests were
missing, and nothing proved any of them could fail. Add them, plus a
Postgres test that LookupByWorkflow returns an error rather than
"not found" on a failed read, and an end-to-end GET on the production
dae24112 incident text. Each was shown red with its bug put back.

Claude-Session: https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 6m 28s —— View job


Review complete — changes requested

  • Read the pinned diff (b5f3eca...e407e48)
  • Check whether the prior P2 findings were addressed
  • Review the new tests for coverage and correctness
  • Build / vet / test — not possible in this sandbox (go build was denied, so compile and test status is unverified; please rely on the validate workflow)
  • Post inline findings and submit verdict

0 P1 · 2 P2 · 4 P3 — review, all inline comments bound to e407e48.

e407e48 is test-only: it adds store_trace_test.go, incident_trace_handlers_test.go and lookup_postgres_test.go and changes no production code, so the previous round's findings carry over. The new tests are well targeted — the dae24112 end-to-end derivation, the "invalid trace input is ignored, not rejected" matrix, and the proof that ListIncidents never touches the audit log — and they do run in CI: validate.yml provides a Postgres service and TEST_DATABASE_URL, despite the stale "this repo has no Postgres service in CI" comments in the helpers.

P2 — should fix before merge

  1. Summary URL regex still swallows trailing punctuation (incident_trace.go:45). ([a-z0-9][a-z0-9.-]*) includes ., so a URL ending a sentence captures wf-1., which IsDNS1123Subdomain rejects → workflow_name_unknown for an incident whose workflow name is right there. The new coverage uses daeSummary, copied verbatim from the production notifier, which happens to put the URL last with no punctuation — i.e. the shape that already worked. (?:^|\s) also means a parenthesised URL never matches at all. One-line fix in the comment. Fix this →
  2. Dedup still desyncs workflow_name from trace_id, and the new test codifies it (store_trace_test.go:144). The assertion is (workflow_name = "wf-first", trace_id = traceB) — run A's name paired with run B's trace, now locked in by a test with no rationale recorded anywhere. Both fields ship on the incident response and through mctl_get_incident; the feature exists so a responder can pivot incident → workflow → trace → logs, and a responder who follows workflow_name to that run's logs gets the wrong run. Either move the pair together, keep first-write-wins for both, or document the split in types.go.

Correction to my previous review

The team == "" branch in GetIncident is not a privilege escalation, and I have downgraded it to P3 (detail). GetWorkflow (handlers_read.go:447) already tolerates an empty team for a caller-supplied workflow name on the same non-admin route group, and audit.RedactEntry masks on value length and credential markers — neither of which a 55-character traceparent trips — so GET /api/v1/workflows/{name} already returns the same trace id to the same caller. No new capability is granted here, and the tightening I suggested would have been stricter than the nearest precedent for no gain.

P3 — nice to have

  1. The empty-team gate matches GetWorkflow but contradicts GetWorkflowLogs, with no comment saying which was intended; the authorization closure is also the only decision-making line in the diff with no test using a real non-admin *auth.User.
  2. Terminal not_reported incidents are still labelled not_derived_on_list on list, telling MCP clients to spend a GET that cannot change the outcome. The one shape that would expose it — non-argo source and no stored trace — is the one the new list test does not seed.
  3. The "exactly one of trace_id/trace_unavailable" invariant still does not hold on the 201 create or 200 patch body, and the new tests assert only status codes and the stored row, so the response bodies remain unchecked.
  4. scanAlert still sets TraceSource, which types.go:82-84 calls response-only — and assertTrace now tests the store as owning it, so the comment, the code and the test disagree three ways. Plus the hard-coded workflows.mctl.ai host.

Verified clean

Column and placeholder ordering across alertColumns + scanAlert is consistent in INSERT (23/$1..$23), Update (12), Get, GetByPrefix and List; no duplicate test identifiers in either package; *countingAuditLog satisfies audit.Log via the embedded *audit.Logger plus its override; the telemetry extraction preserves executor.go's previous traceparent/annotation semantics including the all-zeros rejection; the 2s derivation timeout derives from the request context and cannot leak a goroutine; LookupByWorkflow correctly maps pgx.ErrNoRows to (nil, nil) and the new test covers the found / missing / cancelled-context trio. No tools were added, so the server_test.go count is unaffected.
· branch feat/agents-issue-585-feat-incidents-store-and-return-trace-id

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit e407e48.

No significant issues found.

Comment thread internal/api/incident_trace.go Outdated
traceSourceAuditDerive = "workflow_audit"
)

var workflowURLRe = regexp.MustCompile(`(?:^|\s)https://workflows\.mctl\.ai/workflows/[a-z0-9-]+/([a-z0-9][a-z0-9.-]*)`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Still open from the previous round, and the new tests do not close it.

([a-z0-9][a-z0-9.-]*) includes . and matches greedily to the end of the run, so a URL that terminates a sentence captures the trailing punctuation:

"Workflow failed: https://workflows.mctl.ai/workflows/argo-workflows/mctl-agents-implement-dae24112."

captures mctl-agents-implement-dae24112., which IsDNS1123Subdomain rejects (a subdomain must end alphanumeric), so line 100 falls through to workflow_name_unknown for an incident whose workflow name is right there in the summary. A trailing - behaves the same way.

The new coverage uses daeSummary (incident_trace_handlers_test.go:37), copied verbatim from the production notifier — which happens to emit the URL last with no punctuation. So both the unit case and the new handler case exercise only the shape that already worked; the punctuated shape is still untested.

One-line fix, anchoring the end of the name rather than relying on the character class:

var workflowURLRe = regexp.MustCompile(`(?:^|[\s(])https://workflows\.mctl\.ai/workflows/[a-z0-9-]+/([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)`)

Note the leading class as well: (?:^|\s) means "... failed (https://workflows.mctl.ai/...)" does not match at all, since ( is neither start-of-string nor whitespace. Table cases for a trailing ., a trailing ), and a parenthesised URL would pin both down.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in 869866c. This round reviewed e407e48, which was before that commit. See the reply on 4251856433.

Comment thread internal/alerts/store_trace_test.go Outdated
if got.ID != first.ID || got.OccurrenceCount != 2 {
t.Fatalf("second create did not dedup: id=%q count=%d", got.ID, got.OccurrenceCount)
}
assertTrace(t, "dedup with new trace", got, traceB, "wf-first")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — This test now codifies the workflow_name/trace_id desync rather than resolving it.

The assertion is (workflow_name = "wf-first", trace_id = traceB): run A's name paired with run B's trace. That is exactly the inconsistency raised last round, now locked in by a test, with no comment anywhere explaining why the two fields are allowed to diverge.

Why it matters beyond tidiness: both fields are serialized on the incident response (types.go:79-80) and surfaced through mctl_get_incident. The feature exists so a responder can pivot incident -> workflow -> trace -> logs. A responder who reads workflow_name: wf-first and fetches that run's logs gets the wrong run, because the trace alongside it belongs to wf-second. During an incident that is a wrong answer, not a cosmetic one.

Two self-consistent options:

-- latest-wins for the pair
trace_id      = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END,
workflow_name = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name ELSE alerts.workflow_name END

or first-write-wins for both (drop trace_id from DO UPDATE SET).

If the current split really is deliberate — e.g. "workflow_name names the run that opened the incident, trace_id the most recent one" — that is defensible, but it needs saying in types.go next to the field docs, because nothing in the API tells a client the two refer to different runs. The test comment "workflow_name stays the original" describes the behaviour without justifying it.

Related: line 91 says "workflow_name is immutable", which reads as an enforced invariant. It is actually just that Store.Update does not list the column, so after.WorkflowName = "something-else" is silently dropped rather than rejected. Worth phrasing as "Update does not persist workflow_name" so a future reader does not rely on an immutability guarantee that is not implemented.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in 869866c. This round reviewed e407e48. The test now asserts (wf-second, traceB), so the pair moves together. The "immutable" comment now says "Update does not persist workflow_name" (b87e160).

Comment thread internal/api/handlers_alerts.go Outdated
}
deriveIncidentTrace(r.Context(), a, lookup, func(e *audit.Entry) bool {
team := auditEntryTenant(e)
return user.IsAdmin() || team == "" || user.HasTenantAccess(team)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Correcting my own P2 from the previous round: this is not a privilege escalation. It is a consistency question plus a coverage gap.

I claimed the permissive team == "" branch made this a trace oracle for platform-scoped workflows. Checking the rest of the package, that does not hold:

  • GetWorkflow (handlers_read.go:447) uses !user.IsAdmin() && team != "" && !user.HasTenantAccess(team) — the same tolerance of an empty team, for a caller-supplied workflow name, on the same non-admin route group (router.go:428).
  • audit.RedactEntry masks on value length (>= 100) and credential markers (redact.go:22-36). A 55-character lowercase-hex traceparent matches neither, so GET /api/v1/workflows/{name} already returns audit.parameters.traceparent verbatim to any authenticated non-admin, for any workflow name including empty-team ones.

So the trace id reachable here is already reachable by the same caller through an existing endpoint, and the existence signal is too. No new capability is granted, and my suggested tightening would have made this stricter than the nearest precedent for no gain.

What is genuinely worth addressing:

  1. Two gates coexist for the empty-team case — GetWorkflow permits, GetWorkflowLogs (handlers_read.go:520-531) denies, each with a comment explaining itself. This closure matches the former but says nothing. A one-line comment ("a trace id exposes no more than GetWorkflow already does; step logs are gated separately") would stop the next reader from relitigating it, as I just did.
  2. The closure is the only line in the diff that makes an authorization decision, and no test exercises it with a real *auth.User. incident_trace_test.go covers deriveIncidentTrace with allow/deny stubs, and TestIncidentTrace_GetDerivesDae24112FromAudit only uses adminUser(). A non-admin case over an entry with a team_name they do not have (expect audit_record_forbidden) and one with no team (expect the derived trace) would cover both branches cheaply, using the withUser helper already in the package.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the correction. I kept the stricter gate and documented why. Both branches are now covered with real *auth.User values through GetIncident in TestIncidentTrace_GetAsNonAdmin: own team gives the trace, another team gives audit_record_forbidden, and a platform op gives audit_record_forbidden.

Comment thread internal/api/handlers_alerts.go Outdated
// never read as "this incident has no trace".
for i := range items {
if items[i].TraceID == "" {
items[i].TraceUnavailable = traceNotDerivedOnList

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Still open: not_derived_on_list is applied to incidents where derivation could never produce a trace, and the new test does not cover that case.

For a non-argo-workflows incident, deriveIncidentTrace returns not_reported — terminal. Labelling those not_derived_on_list tells the caller (and the MCP client, per the new tool description: "call this tool to resolve it") to spend a GET /incidents/{id} that cannot change the outcome.

TestIncidentTrace_ListMarksAbsenceWithoutAuditLookup seeds a source: "manual" incident, but gives it a TraceID, so it takes the continue path. The combination that exposes this — non-argo source and no stored trace — is the one shape the test does not seed.

for i := range items {
    if items[i].TraceID != "" {
        continue
    }
    if items[i].Source == incidentSourceArgo {
        items[i].TraceUnavailable = traceNotDerivedOnList
    } else {
        items[i].TraceUnavailable = traceNotReported
    }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in 869866c (see the reply on 4251863225).

}
a := body.Alert
a.TraceID = validIncidentTraceID(body.TraceID)
a.WorkflowName = validIncidentWorkflowName(body.WorkflowName)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Still open: the "exactly one is set" invariant does not hold on the 201 create or the 200 patch response, and the new handler tests assert only the status code and the stored row, not the response body.

incident_trace.go:31 states that exactly one of trace_id / trace_unavailable is set, and the new MCP description promises that a missing trace_id always carries a reason. CreateIncident (201, line 97) and UpdateIncident (200, line 323) return the stored alert without running deriveIncidentTrace and without setting a reason, so an argo-workflows incident created with no trace comes back with both fields empty — the "this incident has no trace" misreading the ListIncidents loop was added to prevent.

TestIncidentTrace_InvalidInputIgnoredNotRejected checks rec.Code and then re-reads through store.Get, so the response bodies of both endpoints are still unasserted. Setting TraceUnavailable = traceNotDerivedOnList (or traceNotReported per the sibling comment) on these two responses would make the contract uniform, and asserting trace_unavailable on the decoded 201 body would lock it in — decodeIncident is already there for it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 869866c, and the response bodies are asserted in b87e160 (see the reply on 4251862373).

- Derivation gate: an audit entry with no team is an AdminOnly platform
  operation, and the workflow name is caller-controlled, so non-admins
  may only derive from entries of their own teams (as GetWorkflowLogs).
- Dedup moves trace_id and workflow_name together; while no trace is
  stored a newer workflow_name replaces the older one.
- Summary URL fallback no longer captures sentence punctuation and
  matches a URL inside parentheses.
- Update keeps the stored trace when the caller carries none.
- Every response labels the trace: write responses say not_derived for
  argo incidents, any non-argo incident without a trace says
  not_reported, and trace_source is set by the handlers, not the store.

Claude-Session: https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7
Comment thread internal/alerts/store.go Outdated
}
a.TraceSource = ""
if a.TraceID != "" {
a.TraceSource = "reported"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Still open, and the new store test now promotes this into the persistence layer's contract.

types.go:82-84 says TraceSource is "response-only and never persisted", yet scanAlert derives it on every read path including List and Summary. store_trace_test.go:29-39 (assertTrace) now asserts that trace_source == "reported" comes back from Create, Get, GetByPrefix and List — so the store is no longer just incidentally setting a presentation field, it is tested as owning one, directly against its own doc comment.

It is harmless at runtime (nothing writes it back, and deriveIncidentTrace resets both fields on the one path that matters). But either the store owns trace_source or the handlers do; right now the comment says one thing, the code does another, and the test pins the code. Dropping the two lines and setting the field in the handlers — with assertTrace checking only the two persisted columns — would make all three agree.

Also in this area, carried over: the Argo UI hostname is hard-coded in incident_trace.go:45 and appears nowhere else in non-test code, so a second or renamed Argo host silently breaks summary-based derivation with no way to configure it. Since producers are expected to send workflow_name directly, the regex is really a legacy-row fallback — worth saying so in the comment, or sourcing the host from the same config the notifier uses.

@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 47s —— View job


Review complete — approve (verdict could not be submitted: head moved)

  • Read the pinned diff (b5f3eca...869866c)
  • Check whether the prior P2/P3 findings were addressed by 869866c
  • Review correctness, security, concurrency, error handling
  • Check test coverage of the new behaviour
  • Build / vet / test — not possible in this sandbox (go build, go vet and go test were denied, so compile and test status is unverified; please rely on CI)
  • Post inline findings
  • Submit verdict — rejected by GitHub: the PR head advanced past 869866c while this run was analyzing it, and GitHub refuses a review bound to a stale commit (422 … has been updated since you started reviewing). The three inline comments below are posted and bound to 869866c; the approving verdict belongs to the review run for the new head.

0 P1 · 0 P2 · 3 P3 for 869866c — would have approved.

Previous findings: all closed

Round-2 finding Status in 869866c
P2 permissive team == "" audit gate Closed — canReadAuditEntry (incident_trace.go:84) is admin, or a non-empty team the caller belongs to, matching GetWorkflowLogs; the choice is now explained in the comment and TestCanReadAuditEntry covers both branches with real *auth.User values
P2 trace_id/workflow_name desync on dedup Closed — the pair moves together (store.go:146-149); TestTraceID_Dedup covers replace-both, keep-both, and newer-name-while-untraced
P2 regex swallowing trailing punctuation Closed — the name is anchored on an alphanumeric and a leading ( is accepted (incident_trace.go:61); TestDeriveIncidentTrace_SummaryURLInProse covers a trailing ., a trailing -, and a parenthesised URL
P3 Update clobbering trace_id Closed — non-destructive CASE, with a partial-update assertion in the round-trip test
P3 invariant broken on create/patch Closed — labelStoredTrace labels every write response, and not_reported is used where derivation could never help, so the "exactly one is set" contract now holds on create, patch, ack, resolve and list
P3 not_derived_on_list on non-argo items Closed — gated on the source, with a source: "manual" untraced item asserted in the list test
P3 store setting the response-only trace_source Closed — scanAlert no longer touches it; the test helper asserts the store leaves it empty

P3 — nice to have (non-blocking)

  1. An explicit ::text cast on the new Update CASE (store.go:209). CASE WHEN $12 <> '' has two untyped operands, so it parses only via Postgres's all-unknown-to-text fallback. It works, but this SQL is new in 869866c and internal/alerts tests t.Skip without TEST_DATABASE_URL — worth confirming the Postgres run was repeated on this commit. Fix this →
  2. One untested dedup combination (store.go:147): an occurrence reporting a trace but no workflow name clears the stale name. That is the right reading of the new policy — keeping the old name next to the new trace is the desync this commit removes — but it is neither tested nor stated in the comment.
  3. Two reason values the MCP description does not list (server.go:2125): write responses now return not_derived, and a non-argo list item returns not_reported, while the text presents not_derived_on_list as what a list item carries.

Verified clean

alertColumns/scanAlert stay in lockstep across INSERT (23 placeholders), Update, Get, GetByPrefix and List; the audit.Log interface addition is implemented by both *Logger and *PostgresLogger, and the only other implementor in the tree embeds *audit.Logger, so the interface change compiles; LookupByWorkflow maps pgx.ErrNoRows to (nil, nil) and wraps every other failure, and its context-cancellation case is tested; the derivation's 2 s timeout derives from the request context and cannot leak a goroutine; the caller-controlled workflow name is DNS1123-validated and only ever reaches a parameterized query; the derived trace is never written back (asserted end to end); no tools were added, so the server_test.go count is unaffected. Note that the Postgres-backed store and incident-handler tests skip in CI, so the new SQL and the handler paths are only exercised with TEST_DATABASE_URL set.
· branch feat/agents-issue-585-feat-incidents-store-and-return-trace-id

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 0 P1, 2 P2, 4 P3.

e407e48 is test-only — it adds store_trace_test.go, incident_trace_handlers_test.go and lookup_postgres_test.go and changes no production code, so the previous round's findings all carry over. The new tests are well targeted (the dae24112 end-to-end derivation, the "invalid trace input is ignored, not rejected" matrix, and the proof that ListIncidents never touches the audit log) and they do run in CI, since validate.yml provides a Postgres service and TEST_DATABASE_URL.

P2: (1) The summary URL regex still captures a trailing ./-, so a URL ending a sentence fails DNS1123 validation and yields workflow_name_unknown; the new coverage uses the production summary, which happens to be the shape that already worked. (2) The Create dedup path still updates trace_id without workflow_name, and store_trace_test.go:144 now asserts that pairing (run A's name, run B's trace) as intended, with no rationale recorded — a responder following workflow_name to a run's logs gets the wrong run.

Correction to my previous review: the team == "" branch in GetIncident is not a privilege escalation and is no longer a P2. GetWorkflow (handlers_read.go:447) already tolerates an empty team for a caller-supplied workflow name on the same non-admin route, and audit.RedactEntry does not mask a traceparent, so the same trace id is already reachable through an existing endpoint. It remains a P3 for the undocumented divergence from GetWorkflowLogs and for the authorization closure having no test with a real non-admin user.

Remaining P3s: terminal not_reported incidents are labelled not_derived_on_list on list; the "exactly one of trace_id/trace_unavailable" invariant still does not hold on the 201/200 bodies, which the new tests do not assert; scanAlert still sets the response-only TraceSource, now asserted by assertTrace.

go build and go test were not available in this review sandbox, so compile and test status is unverified — please rely on the validate workflow.

Exercise canReadAuditEntry through GetIncident with real non-admin users
and assert the create/patch response bodies, both shown failing with the
behaviour reverted.

Claude-Session: https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 7m 4s —— View job


Review complete — changes requested

  • Read the pinned diff (b5f3eca...b87e160)
  • Check whether the prior P2/P3 findings were addressed by 869866c / b87e160
  • Review correctness, security, concurrency, error handling
  • Check test coverage of the new behaviour
  • Build / vet / test — not possible in this sandbox (go build, go vet and go test were denied, so compile, lint and test status is unverified; please rely on the validate workflow)
  • Post inline findings and submit verdict

0 P1 · 1 P2 · 3 P3 — review, all inline comments bound to b87e160.

Previous findings: addressed

869866c closed every P2 from the earlier rounds, and b87e160 added the tests for the parts that were behaviour-only:

  • Summary URL regex now anchors the end of the name and accepts a leading (. TestDeriveIncidentTrace_SummaryURLInProse covers a trailing period, a parenthesised URL, both together, and a trailing hyphen.
  • Dedup moves trace_id and workflow_name together, and promotes a newer workflow_name while the row is untraced. TestTraceID_Dedup was rewritten around the pair.
  • Store.Update is non-destructive (CASE WHEN $12 <> '' …), with a partial-update case asserting the trace survives.
  • scanAlert no longer sets the response-only TraceSource; the handlers own it via the new labelStoredTrace, and assertTrace now asserts the store leaves it empty. Comment, code and test agree.
  • The authorization gate moved into canReadAuditEntry, which is the stricter GetWorkflowLogs rule (admin, or a non-empty team the caller belongs to). The divergence from GetWorkflow is documented, and TestCanReadAuditEntry plus TestIncidentTrace_GetAsNonAdmin exercise it with real non-admin *auth.Users, including the platform-op case.
  • Response labelling is uniform: write responses get not_derived, list gets not_derived_on_list, and a non-argo incident gets terminal not_reported on both — covered by TestIncidentTrace_WriteResponsesAreLabelled and the extra manual-untraced item in the list test.
  • Const blocks split; the regex comment now says it is the legacy fallback.

P2 — should fix before merge

  1. A deduped recurring argo incident derives the first occurrence's trace (handlers_alerts.go:65). The producer sends no workflow_name (per the workflowURLRe comment, that is every argo incident today), so the dedup clause that refreshes it can never fire — and summary is not in the DO UPDATE SET list. Run 2 fails into the open row, occurrence_count=2, and GET /incidents/{id} parses run 1's URL out of the stale summary and returns run 1's trace as trace_source: "workflow_audit", with no trace_unavailable and nothing marking it stale. For the incident → trace → Loki pivot this PR exists for, that is a wrong answer rather than a missing one. Smallest in-scope fix: fall back to the summary regex in CreateIncident so the stored workflow_name tracks the newest occurrence — the dedup clause already handles it from there. No test runs GET on a deduped argo incident.

P3 — nice to have

  1. The first WHEN arm of the dedup workflow_name CASE blanks a stored name when the incoming occurrence carries a trace but no name — the one combination TestTraceID_Dedup omits. store.go:147
  2. The mctl_get_incident description still says list results carry not_derived_on_list, which this commit made untrue for non-argo items, and never mentions the new not_derived. As written it sends the model to spend a GET on exactly the terminal items. server.go:2125
  3. No representable request can clear a wrong trace_id (both the CASE and validIncidentTraceID map absent and invalid to ""), and workflow_name cannot be patched at all — worth a line in the types.go field docs. store.go:209

Verified clean

alertColumns / scanAlert ordering is consistent across INSERT (23 columns / $1..$23), Update, Get, GetByPrefix and List; pgx.Rows satisfies pgx.Row; the audit.Log interface addition is implemented by *Logger, *PostgresLogger and the test countingAuditLog, and no other in-repo type implements it; the telemetry extraction preserves executor.go's traceparent and annotation semantics including the all-zeros rejection, and strings is still used elsewhere in that file; the 2 s derivation timeout derives from the request context with defer cancel and leaks no goroutine; IncidentSummary returns counts only, so every alert-returning response is labelled; LookupByWorkflow maps pgx.ErrNoRows to (nil, nil) and wraps other failures. No MCP tools were added, so the server_test.go count is unaffected.

One housekeeping note: the SQL fragment in finding 1's comment body got mangled by shell quoting — the clause it refers to is alerts.trace_id = '' AND EXCLUDED.workflow_name <> '', and the inline comment API does not allow edits from here.
· branch feat/agents-issue-585-feat-incidents-store-and-return-trace-id

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 869866c.

No significant issues found.

Comment thread internal/alerts/store.go Outdated
`UPDATE alerts SET status=$2, analysis=$3, proposed_fix=$4, pr_url=$5, pr_number=$6,
confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11
confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11,
trace_id=CASE WHEN $12 <> '' THEN $12 ELSE trace_id END

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the new non-destructive CASE is right, but it leans on Postgres resolving an all-unknown operator to text, and CI cannot catch it if that ever fails.

The fix itself addresses the previous round's finding: all three callers keep their stored trace, and the new partial-update case in TestTraceID_RoundTripsThroughEveryReadPath pins it.

One caveat on the form. In CASE WHEN $12 <> '' both operands arrive untyped, so the statement parses only because Postgres falls back to text when every input to an operator is unknown. It does fall back, so this works today — but the failure mode if it ever did not is every incident PATCH/ack/resolve returning 500, and internal/alerts tests t.Skip without TEST_DATABASE_URL, which is the CI default in this repo. This SQL is new in 869866c (the previous commit had the bare trace_id=$12), so it is worth confirming the Postgres-backed run was repeated on this commit rather than only on e407e48.

Making the type explicit removes the dependence on the fallback:

trace_id=CASE WHEN $12::text <> '' THEN $12::text ELSE trace_id END

Comment thread internal/alerts/store.go
updated_at = EXCLUDED.updated_at,
trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END,
workflow_name = CASE
WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — this branch blanks a known workflow_name, which is the correct reading of the new "they move together" policy but is the one dedup combination the test does not cover.

The pairing fix is good: TestTraceID_Dedup now covers new-trace-replaces-both, no-trace-keeps-both, and newer-name-while-untraced. What is left unexercised is EXCLUDED.trace_id <> '' AND EXCLUDED.workflow_name = '' — an occurrence that reports a trace but no workflow name. The first WHEN then assigns the empty EXCLUDED.workflow_name, so a row that held (wf-a, "") becomes ("", T_b): the incident keeps a trace but loses the workflow link it previously had in the response.

That is self-consistent (the alternative, keeping wf-a next to T_b, is exactly the desync this commit removes), so no SQL change is needed. But a future reader will ask, and the comment above the CASE does not say it. One line in TestTraceID_Dedup:

assertTrace(t, "a trace without a name clears the stale name", occur("argo-dedup-4", fp, traceA, "", 4), traceA, "")

plus a half-sentence in the doc comment ("a trace reported without a name clears the stale name rather than pairing it with the new trace") would make the choice deliberate rather than incidental.

Comment thread internal/mcp/server.go Outdated
trace_source "reported" (sent by the producer) or "workflow_audit" (derived from the
failed workflow's submit audit record). When no trace id could be obtained,
trace_unavailable gives the reason. A missing trace_id means "not known", never
"no trace exists". List results carry trace_unavailable "not_derived_on_list"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the description enumerates the reason set, and 869866c added two values it does not mention.

The write responses (create, patch, ack, resolve) now return trace_unavailable: "not_derived", and a non-argo incident on a list response gets "not_reported" rather than "not_derived_on_list". Both are improvements over the previous round's "both fields empty", but an MCP client that reads this paragraph as the complete set now sees undocumented values — and the current text specifically tells it a list item carries not_derived_on_list, which is only true for argo incidents.

A sentence covering both keeps the tool description the single source of truth for the contract:

trace_unavailable is not_reported when no trace can exist for this incident source, not_derived_on_list for an argo incident on a list response (call this tool to resolve it), not_derived on a create/patch/ack/resolve response, or one of workflow_name_unknown / audit_unavailable / audit_record_not_found / audit_record_forbidden / no_traceparent_in_audit_record when the derivation ran and could not produce one.

}
a := body.Alert
a.TraceID = validIncidentTraceID(body.TraceID)
a.WorkflowName = validIncidentWorkflowName(body.WorkflowName)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — For a deduped recurring argo incident, the derived trace is pinned to the first occurrence, so the responder gets the oldest run.

validIncidentWorkflowName leaves a.WorkflowName empty whenever the producer omits it — which the comment at incident_trace.go:52-53 says is every argo incident today. The Create dedup path (store.go:140-149) deliberately refreshes workflow_name from a newer occurrence while the row is untraced, but that clause can never fire if the producer never sends the field, and summary is not in the DO UPDATE SET list at all. So:

  • run 1 fails -> row stored with run 1 summary (.../mctl-agents-implement-aaa11111), no trace, no name
  • run 2 fails, same fingerprint, incident still open -> dedup bumps occurrence_count=2 and last_seen_at, summary and workflow name unchanged
  • GET /incidents/{id} -> workflowURLRe reads run 1 out of the stale summary -> trace_id of run 1, labelled workflow_audit

Nothing in the response says the trace is two runs old: occurrence_count and last_seen_at point at run 2, trace_source claims a derived trace, and there is no trace_unavailable. For the use case this PR exists for — pivot incident -> trace -> Loki to see why it just failed — that is a wrong answer, not a missing one. The dedup window is real: the ON CONFLICT predicate only excludes resolved/suppressed, so every retry while the incident is open lands here.

Smallest in-scope fix is to fill the field the dedup clause already handles, right here:

a.WorkflowName = validIncidentWorkflowName(body.WorkflowName)
if a.WorkflowName == "" {
    a.WorkflowName = workflowNameFromSummary(a.Summary) // the workflowURLRe match, factored out
}

Then occurrence 2 supplies wf-run2, the alerts.trace_id = '"' AND EXCLUDED.workflow_name <> '"' clause promotes it, and deriveIncidentTrace prefers a.WorkflowName over the stale summary. It also makes the stored row self-describing instead of depending on a regex over prose at read time.

Either way this needs a test: TestTraceID_Dedup covers the columns but never the derivation, and TestIncidentTrace_GetDerivesDae24112FromAudit uses a single-occurrence row, so no test exercises GET on a deduped argo incident. If the staleness is accepted instead, it should be stated in the trace_source docs ("workflow_audit names the run recorded in the incident, which for a recurring incident is the first occurrence").

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f2472b6. CreateIncident now stores the workflow name parsed from the summary for argo incidents when the producer sends none (workflowNameFromSummary, shared with derivation). The dedup clause carries workflow_name forward on each occurrence while no trace is stored, so derivation follows the newest run. Covered by TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun, which posts run1 and run2 under one fingerprint and expects run2's trace. Mutation check: removing the fallback makes that test fail with derived trace_id=008ad897... workflow_name=<nil>, want the newest run.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit b87e160.

No significant issues found.

Comment thread internal/alerts/store.go
updated_at = EXCLUDED.updated_at,
trace_id = CASE WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.trace_id ELSE alerts.trace_id END,
workflow_name = CASE
WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The first WHEN arm blanks a stored workflow_name, and that case is the one the new dedup test does not cover.

WHEN EXCLUDED.trace_id <> '' THEN EXCLUDED.workflow_name assigns the incoming name unconditionally, including when it is empty. So an occurrence that carries a trace but no workflow name erases a name the row already had:

stored incoming result
('', 'wf-a') (traceB, '') (traceB, '') — wf-a gone

That is the shape a producer will have as soon as it starts sending trace_id (the obvious first step) without also sending workflow_name. Arguably it keeps the pair honest — the name of run B genuinely is unknown — but the row then names no workflow at all while summary still links run A, so the "move together" invariant is not actually achieved either; it just drops the one field that was accurate.

TestTraceID_Dedup covers (trace, name), ('', name) and ('', ''), but never (trace, ''), so whichever behaviour is intended is currently unpinned. If keeping wf-a is preferred, the arm is the same CASE shape used for trace_id:

workflow_name = CASE
    WHEN EXCLUDED.workflow_name <> '' THEN EXCLUDED.workflow_name
    WHEN EXCLUDED.trace_id <> '' THEN ''
    ELSE alerts.workflow_name END

...or, if blanking is intended, add the row to the test table so a future reader knows it was a decision.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended, and now documented and tested in f2472b6. trace_id and workflow_name describe the same run, so a new trace with no name must not stay paired with the previous run's name. There is a comment on the clause, and a new TestTraceID_Dedup case "a trace without a name clears the stale name". Mutation check: guarding the first arm with AND EXCLUDED.workflow_name <> '' makes that case fail.

Comment thread internal/mcp/server.go Outdated
trace_source "reported" (sent by the producer) or "workflow_audit" (derived from the
failed workflow's submit audit record). When no trace id could be obtained,
trace_unavailable gives the reason. A missing trace_id means "not known", never
"no trace exists". List results carry trace_unavailable "not_derived_on_list"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The description is now out of date with the two labelling changes in this commit.

"List results carry trace_unavailable not_derived_on_list" was true of the previous commit, which labelled every traceless item that way. After labelStoredTrace, a non-argo item on the list gets not_reported instead (asserted by TestIncidentTrace_ListMarksAbsenceWithoutAuditLookup), and that one is terminal — calling mctl_get_incident on it cannot change the answer. As written the description sends the model to spend a GET on exactly the items where it is pointless, which is the cost the not_reported split was introduced to avoid.

The new not_derived reason returned by the create/patch/ack/resolve responses is also undocumented anywhere a client can see it.

Suggest naming the two actionable reasons explicitly, e.g.:

trace_unavailable gives the reason a trace id is absent. "not_derived_on_list"
(list results) and "not_derived" (write responses) mean the derivation was not
run — call this tool to resolve them. Every other reason, including
"not_reported", is terminal.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f2472b6. The mctl_get_incident description now lists every trace_unavailable value and says when each one appears (list vs get).

Comment thread internal/alerts/store.go Outdated
`UPDATE alerts SET status=$2, analysis=$3, proposed_fix=$4, pr_url=$5, pr_number=$6,
confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11
confidence=$7, updated_at=$8, resolved_at=$9, acknowledged_by=$10, acknowledged_at=$11,
trace_id=CASE WHEN $12 <> '' THEN $12 ELSE trace_id END

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — Non-destructive now (good), but it closes the only door for correcting a wrong trace id.

Between this CASE and validIncidentTraceID mapping both "absent" and "invalid" to the empty string, there is no representable request that clears trace_id. If a producer ever posts a syntactically valid but wrong trace (a parent trace, a trace from the wrong leg), the incident is stuck reporting it as trace_source: "reported" forever — and reported beats derivation in deriveIncidentTrace, so the correct derivable trace stays hidden.

Not worth blocking on, and the empty-means-keep rule is the right default for the partial-update callers. Worth a line in the TraceID doc comment (types.go:78-81) saying the value is write-once-per-value and cannot be reset through the API, so the next person does not go looking for the clear path.

Same for workflow_name: it is absent from this SET list and from the UpdateIncident patch struct, so a row created without one can only ever gain one through a dedup hit. The store_trace_test.go:84-85 comment now records that accurately, but the API surface does not.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged and documented in f2472b6 (types.go doc on TraceID/WorkflowName). No API path clears a stored trace, and PATCH never sets workflow_name. A wrong trace can be corrected by PATCHing a new valid trace_id or by a newer occurrence that carries one. Supporting an explicit clear would need a tri-state field in the PATCH body, which is out of scope here. The $12::text cast is applied in the same commit.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 0 P1, 1 P2, 3 P3.

869866c addressed every P2 from the previous rounds: the summary URL regex now anchors the end of the name and accepts a leading open paren (covered by TestDeriveIncidentTrace_SummaryURLInProse, including the trailing period, paren and hyphen shapes); the dedup moves trace_id and workflow_name together; Store.Update is non-destructive; scanAlert no longer sets the response-only TraceSource; canReadAuditEntry is the stricter GetWorkflowLogs gate with the divergence from GetWorkflow documented and tested against real non-admin users; write and list responses are labelled, with not_reported kept terminal for non-argo items. b87e160 adds the tests for the gate and the write-response labels.

Remaining P2: for a recurring argo incident that dedups while still open, the derived trace is the FIRST occurrence's run. The producer sends no workflow_name (per the comment on workflowURLRe), so the dedup clause that refreshes it can never fire, and summary is not in the DO UPDATE SET list — GET parses run 1's URL out of the stale summary and returns run 1's trace as trace_source=workflow_audit while occurrence_count and last_seen_at describe the latest run. Nothing in the response marks it stale. Smallest fix is to fall back to the summary regex in CreateIncident so the stored workflow_name tracks the newest occurrence, which the existing dedup clause already handles. No test exercises GET on a deduped argo incident.

P3: the first WHEN arm of the dedup workflow_name CASE blanks a stored name when the incoming occurrence has a trace but no name, the one combination the new dedup test omits; the mctl_get_incident description still says list results carry not_derived_on_list, now untrue for non-argo items, and does not mention the new not_derived; no representable request can clear a wrong trace_id, and workflow_name cannot be patched at all.

Verified: alertColumns/scanAlert ordering is consistent across INSERT (23 columns / $1..$23), Update, Get, GetByPrefix and List; pgx.Rows satisfies pgx.Row; the audit.Log addition is implemented by *Logger, *PostgresLogger and the test countingAuditLog, and no other in-repo type implements the interface; the telemetry extraction preserves executor.go's traceparent and annotation semantics including the all-zeros rejection, and strings is still used elsewhere in that file; the 2s derivation timeout derives from the request context with defer cancel and leaks no goroutine; IncidentSummary returns counts only, so every alert-returning response is labelled. No MCP tools were added, so the server_test.go count is unaffected.

go build, go vet and go test were not available in this review sandbox, so compile, lint and test status is unverified — please rely on the validate workflow.

The argo producer sends no workflow_name and the dedup path keeps the
first summary, so a recurring incident kept deriving the first run's
trace. CreateIncident now stores the workflow name parsed from the
summary, and the dedup clause carries it forward with each occurrence.

Also casts the Update trace parameter to text, documents that a trace
without a name clears the stale name (with a test), lists every
trace_unavailable reason in the MCP description, and documents that no
API path clears the trace and PATCH never sets workflow_name.

Claude-Session: https://claude.ai/code/session_0179eB1H7jwPMMcfY5h1TdV7
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 58s —— View job


Review complete — approved

  • Read the pinned diff (b5f3eca...f2472b6)
  • Check whether the prior P2/P3 findings were addressed by f2472b6
  • Review correctness, security, concurrency, error handling
  • Check test coverage of the new behaviour
  • Build / vet / test — not possible in this sandbox (go build, go vet and go test were denied, so compile, lint and test status is unverified here; please rely on the validate workflow)
  • Post inline findings and submit verdict

0 P1 · 0 P2 · 2 P3 — review, both inline comments bound to f2472b6.

Previous findings: all closed

Finding Status in f2472b6
P2 deduped argo incident derives the first occurrence's trace Closed at the source — CreateIncident (handlers_alerts.go:66-71) stores the name parsed from the summary for argo incidents, so the dedup clause that promotes a newer name while the row is untraced can actually fire. TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun runs two POSTs and a GET through the handlers with two real audit entries; run 1 is seeded with a different traceparent, so a regression fails loudly
P3 untested (trace, no name) dedup combination Closed — TestTraceID_Dedup gains the row, and the rule is stated in the SQL comment
P3 mctl_get_incident reason set out of date Closed — the description enumerates not_reported, not_derived_on_list, not_derived and the five derivation reasons, and says which are actionable
P3 untyped CASE operands in Update Closed — $12::text

P3 — nice to have (non-blocking)

  1. types.go:80 overstates the dedup rule. "They describe the newest occurrence" is not what the keep-the-pair branch implements: with a trace already stored, the pair is pinned to the newest occurrence that reported a trace. Once producers start sending trace_id — the point of feat(incidents): store and return trace_id from mctl_get_incident #585 — a recurring incident returns the first traced run as trace_source: "reported" with occurrence_count > 1, last_seen_at = now and no staleness marker. Keeping the trace is the right default; the sentence just needs narrowing. comment
  2. summary is still absent from the dedup SET. So a deduped response now pairs run 2's workflow_name/trace_id with run 1's Argo UI link — the state the new test asserts. summary is what a human reads in the Telegram notification, so the wrong-run pivot survives in the other direction. Either add summary = EXCLUDED.summary or say so in the field docs. comment

One correction to the PR body

The DB-gated tests do not skip in CI. validate.yml runs a postgres:16 service, exports TEST_DATABASE_URL, and already uses go test -p 1 for exactly the cross-package DELETE race the body describes — so internal/alerts, internal/api and internal/audit all exercise the new SQL and handler paths there. The "this repo has no Postgres service in CI" comment in handlers_alerts_test.go:31-33 is stale and predates this PR.

Verified clean on this commit

alertColumns/scanAlert stay in lockstep across INSERT (23 columns / $1..$23), Update, Get, GetByPrefix and List, and Summary selects counts only; every alert-returning response is labelled exactly once, and resolve-by-fingerprint returns a bool; all DO UPDATE SET expressions read the pre-update row, so the two CASEs cannot observe each other's assignment; the caller-controlled workflow name is DNS1123-validated and only ever reaches a parameterized query, gated by canReadAuditEntry; the derivation's 2 s timeout derives from the request context with defer cancel and the derived trace is never written back; LookupByWorkflow maps pgx.ErrNoRows to (nil, nil) and wraps every other failure, and PostgresLogger.Log is synchronous so the new lookup test has no write/read race; the telemetry extraction preserves executor.go's traceparent and annotation semantics including the all-zeros rejection. No MCP tools were added, so the server_test.go count is unaffected.
· branch feat/agents-issue-585-feat-incidents-store-and-return-trace-id

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit f2472b6.

No significant issues found.

Comment thread internal/alerts/types.go
Evidence []Evidence `json:"evidence,omitempty"`
// TraceID is the 32-hex W3C trace id reported for the run that handled
// this incident. WorkflowName is the Argo workflow it came from. Both are
// stored; empty means not reported. They describe the newest occurrence

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — "They describe the newest occurrence" is not quite what the dedup rules implement, and the gap is the same staleness f2472b6 just fixed on the other path.

The dedup CASE keeps the pair when the new occurrence carries no trace (store.go:146-152, covered by TestTraceID_Dedup as "no trace keeps the pair"). So with a stored trace the pair is pinned to the newest occurrence that reported a trace, not the newest occurrence:

  • occurrence 1 posts trace_id=T1, workflow_name=run1 -> row (T1, run1)
  • occurrence 2 posts no trace (its leg dropped the traceparent), summary -> run2 -> row stays (T1, run1)
  • GET returns T1 with trace_source: "reported", occurrence_count: 2, last_seen_at = now, and no trace_unavailable

That is the first-run answer presented as fresh — exactly what the workflow_name fallback in CreateIncident was added to stop on the derived path. It is harmless today, because the argo producers send neither field (per the workflowURLRe comment), but the whole point of #585 is that producers start sending trace_id, at which point this is the live shape.

Keeping the trace rather than dropping it is the right default, so no behaviour change is needed. Worth narrowing the sentence to what the code does, e.g. "they describe the newest occurrence that reported a trace, or the newest occurrence while none has", so a responder reading trace_source: "reported" on a recurring incident knows it may predate last_seen_at.

// name from it so a dedup hit, which keeps the first occurrence's
// summary, still records the newest run and derivation follows it.
if a.WorkflowName == "" && a.Source == incidentSourceArgo {
a.WorkflowName = workflowNameFromSummary(a.Summary)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — The fallback fixes the trace, and leaves the response naming two different runs.

This closes the stale-derivation finding: the stored workflow_name now tracks the newest occurrence, the dedup clause promotes it while the row is untraced, and TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun proves GET derives run 2 rather than run 1. Good fix, and the test is the right shape — two real audit entries, two POSTs, one GET through the handler.

The leftover is that summary is still not in the dedup DO UPDATE SET list, so after this change a deduped incident returns workflow_name/trace_id for run 2 next to a summary whose Argo UI link is run 1 — the new test asserts exactly that state (id is ...-run1, workflow_name is ...-run2). summary is the field a human actually reads in a Telegram notification, so a responder who clicks it lands on the run that is not the one the trace describes, which is the same wrong-run pivot in the other direction.

Two cheap options: add summary = EXCLUDED.summary to the dedup SET so the whole row describes the newest occurrence (it is already the semantics last_seen_at and occurrence_count imply), or note in the Summary field docs that it is the first occurrence's text while the trace fields are the newest. Either way worth a line, since the divergence is now deliberate rather than incidental.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (2 P3). Good to merge.

f2472b6 closes the last P2 and all three P3s from the previous round. The deduped-argo case is fixed at the source: CreateIncident now stores the workflow name parsed from the summary for argo incidents, so the dedup clause that promotes a newer name while the row is untraced actually fires, and TestIncidentTrace_DedupedArgoIncidentFollowsNewestRun proves end to end that GET derives run 2 rather than run 1 (run 1 is seeded with a different traceparent, so a regression fails loudly). workflowNameFromSummary is factored out and shared with the derivation path. Update now casts $12::text so the non-destructive CASE no longer depends on the all-unknown-to-text fallback. TestTraceID_Dedup gains the (trace, no name) row, and the rule is stated in the SQL comment. The mctl_get_incident description now enumerates the full reason set including not_derived and terminal not_reported.

Re-verified on this commit: alertColumns/scanAlert stay in lockstep across INSERT (23 columns / $1..$23), Update, Get, GetByPrefix and List, and Summary selects only counts; every alert-returning response is labelled exactly once (create, patch, ack, resolve via labelStoredTrace, list via labelStoredTrace, GET via deriveIncidentTrace), and resolve-by-fingerprint returns a bool; all DO UPDATE SET expressions read the pre-update row, so the two CASEs cannot observe each other; the caller-controlled workflow name is DNS1123-validated and only reaches a parameterized query, gated by canReadAuditEntry; the derivation timeout derives from the request context with defer cancel and the derived trace is never written back; LookupByWorkflow maps pgx.ErrNoRows to (nil, nil), wraps every other failure, and PostgresLogger.Log is synchronous, so the new lookup test has no write/read race; the telemetry extraction preserves executor.go s traceparent and annotation semantics including the all-zeros rejection. No MCP tools were added, so the server_test.go count is unaffected.

P3 (non-blocking): the types.go sentence "they describe the newest occurrence" is not what the keep-the-pair dedup rule implements — with a stored trace the pair is pinned to the newest occurrence that reported one, so once producers start sending trace_id a recurring incident will return the first traced run as trace_source=reported with no staleness marker; and summary is still absent from the dedup SET, so a deduped response now pairs run 2 s workflow_name/trace_id with run 1 s summary link.

One correction to the PR body: the DB-gated tests do NOT skip in CI. validate.yml runs a postgres:16 service and exports TEST_DATABASE_URL (and go test -p 1 for exactly the cross-package DELETE race the body describes), so internal/alerts, internal/api and internal/audit all exercise the new SQL and handler paths there. The "this repo has no Postgres service in CI" comment in handlers_alerts_test.go is stale and predates this PR.

go build, go vet and go test were denied in this review sandbox, so compile, lint and test status is unverified here — please rely on the validate workflow.

@mashkovd
mashkovd merged commit 7d08afc into main Oct 12, 2026
10 checks passed
@mashkovd
mashkovd deleted the feat/agents-issue-585-feat-incidents-store-and-return-trace-id branch October 12, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(incidents): store and return trace_id from mctl_get_incident

1 participant